Skip to content

docs: score frontier portfolios with jquantstats - #872

Merged
tschm merged 1 commit into
mainfrom
docs/jquantstats-portfolio-analytics
Sep 2, 2026
Merged

docs: score frontier portfolios with jquantstats#872
tschm merged 1 commit into
mainfrom
docs/jquantstats-portfolio-analytics

Conversation

@tschm

@tschm tschm commented Sep 2, 2026

Copy link
Copy Markdown
Member

What

cvxcla reports what a portfolio is expected to do — Frontier exposes the expected return, volatility and Sharpe ratio implied by the mean/covariance it was handed. Nothing in the repo asked what those portfolios actually did, because a frontier is a set of weight vectors and the analytics libraries all want a return series.

This adds jquantstats as a dev dependency — it is never imported from src/ — and uses it in one new experiment and both marimo notebooks.

experiments/frontier_stats.py (new)

The out-of-sample version. Fits mean/covariance on the first half of the committed S&P 500 snapshot, traces the frontier, holds the max-Sharpe and min-variance portfolios fixed through the second half, and prints cvxcla's expected figures above jquantstats' realised ones.

At all 494 assets (--assets 494, ~1s):

assets                  : 494
turning points          : 78
max_sharpe              : return  48.82%  volatility 18.39%  Sharpe  2.66  names held  14
min_variance            : return   5.81%  volatility 10.93%  Sharpe  0.53  names held  37
equal_weight            : return   9.78%  volatility 19.00%  Sharpe  0.51  names held 494
realised (jquantstats) max_sharpe min_variance equal_weight
volatility 20.17% 10.61% 14.62%
sharpe 1.74 1.68 1.21
max_drawdown −13.97% −8.36% −17.76%
calmar 2.81 2.25 1.02
kurtosis 3.87 4.94 15.24

The contrast is the textbook asymmetry: min-variance was predicted at 10.93% volatility and realised 10.61% (ratio 0.97, out-of-sample), while its Sharpe moved 0.53 → 1.68. The risk estimate survives out-of-sample; the mean estimate does not.

Verified the 494-asset trace is sound: λ runs from ∞ down to exactly 0, the last point is the variance minimiser, the budget holds to 1e-12, no negative weights, cond(S) = 5.5e5 (ill-conditioned but full rank, T=606 > N=494). The 78 turning points track active-set changes along the path, not asset count — max-Sharpe holds 14 of 494 names, so most box constraints are never activated.

book/marimo/notebooks/cla.py

Previously built mean=randn(n) and covariance=factor@factor.T out of thin air, so there was no return series to analyse at all. It now simulates a history (assets differ in true drift and factor loadings), estimates both from it, traces the frontier as before, then reports a jquantstats summary table and cumulative-return plot for max-Sharpe / min-variance / equal-weight. Verified across the whole slider range (n=4…100).

book/marimo/notebooks/factor.py

Gains a check that is on-theme for a notebook about an exact operator: the volatility the Woodbury quadratic form predicts, sqrt(wᵀΣw), against the volatility jquantstats measures on the realised series. The ratio sits within a few percent of 1 for n=200…1000.

Volatility only, deliberately — simulate_returns draws from a standard normal (~140% per period), so compounded drawdowns came out at −18000% and every return-based metric would be measuring the simulation's conventions rather than the frontier. That function is left untouched (rescaling it would shift the existing frontier plot and interact badly with the notebook's unrelated rng.uniform(0, 0.1) means) and the notebook prose says why, pointing return-based metrics at the new experiment instead.

Dependencies

jquantstats==0.11.0 and polars==1.44.1 in the dev group. polars is declared rather than leaned on transitively because frontier_stats.py imports it directly, and it reads the parquet snapshot without pulling in a pyarrow engine. Both got [tool.deptry.package_module_name_map] entries, since make deps scans book/marimo/notebooks.

Gates

fmt, deps, test (100% coverage held), typecheck, license, security, marimo-validate all pass locally. No change to src/.

Pre-existing bug found, not fixed here

factor.py crashes at the leftmost slider position (n=100), confirmed on the committed version at HEAD. MP clipping keeps k=0 factors there, and cvx.linalg's FactorCovariance.rcond_free does np.linalg.svd(u_free, compute_uv=False)[0] on an empty block → IndexError. The fix belongs in cvx-linalg (or in the slider's lower bound), so it is left alone. marimo-validate passes because it runs the default n=500.

🤖 Generated with Claude Code

cvxcla reports what a portfolio is *expected* to do. Nothing in the repo
asked what those portfolios actually did, because a frontier is a set of
weight vectors and the analytics libraries all want a return series.
jquantstats closes that gap, as a dev dependency: it is never imported
from src/.

experiments/frontier_stats.py (new) is the out-of-sample version. It fits
mean/covariance on the first half of the committed S&P 500 snapshot,
traces the frontier, holds the max-Sharpe and min-variance portfolios
fixed through the second half, and prints cvxcla's expected figures above
jquantstats' realised ones. At all 494 assets the contrast is the
textbook asymmetry: min-variance was predicted at 10.93% volatility and
realised 10.61%, while its Sharpe moved 0.53 -> 1.68. The risk estimate
survives out-of-sample, the mean estimate does not.

book/marimo/notebooks/cla.py built mean=randn(n) and
covariance=factor@factor.T out of thin air, so there was no return series
to analyse at all. It now simulates a history, estimates both from it,
traces the frontier as before, and reports a summary table and a
cumulative-return plot. Verified across the whole slider range.

book/marimo/notebooks/factor.py gains a check that is on-theme for a
notebook about an exact operator: the volatility the Woodbury quadratic
form predicts against the volatility jquantstats measures on the realised
series, ratio within a few percent of 1 for n=200..1000. Volatility only,
deliberately -- simulate_returns draws from a standard normal, so
compounded drawdowns come out at -18000% and every return-based metric
would be measuring the simulation's conventions rather than the frontier.
That function is left untouched and the notebook says why, pointing
return-based metrics at the new experiment instead.

polars is declared alongside jquantstats because frontier_stats.py
imports it directly, and it reads the parquet snapshot without pulling in
a pyarrow engine. Both get deptry package_module_name_map entries, since
`make deps` scans book/marimo/notebooks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tschm
tschm merged commit b0df82c into main Sep 2, 2026
58 checks passed
@tschm
tschm deleted the docs/jquantstats-portfolio-analytics branch September 2, 2026 10:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant